/*
 * Permission to use, copy, modify, and/or distribute this software for
 * any purpose with or without fee is hereby granted.
 *
 * THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
 * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
 * FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
 * DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
 * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

///
/// Provides read and write access to the query portion of a URL string.
///
/// Authors: Mio
/// Date: December 03, 2023
/// Homepage: https://codeberg.org/supercell/mlib
/// License: 0BSD
/// Standards: https://url.spec.whatwg.org/#urlsearchparams
/// Version: 2023.12.03
///
/// History:
///      2023.12.03 is the initial version.
///
module mlib.search_params;

enum SearchParamsVersionYear = 2023;
enum SearchParamsVersionMonth = 12;
enum SearchParamsVersionDay = 3;

enum SearchParamsVersion =
   SearchParamsVersionYear  * 10_000 +
   SearchParamsVersionMonth *    100 +
   SearchParamsVersionDay;

private string buildVersionString() {
   import std.format : format;
   return format!"%04d.%02d.%02d"(SearchParamsVersionYear,
      SearchParamsVersionMonth, SearchParamsVersionDay);
}

enum SearchParamsVersionString = buildVersionString();

private struct pair
{
   string name;
   string value;
}

private pair[] parse(string input) @trusted pure
{
   import std.array : replace, split;
   import std.string : indexOf;
   import std.uri : decodeComponent;

   auto sequences = split(input, '&');
   pair[] output;
   foreach(bytes; sequences) {
      string name;
      string value;

      if (bytes.length == 0) {
         continue;
      }
      if (auto pos = bytes.indexOf("=")) {
         name = bytes[0..pos];
         value = bytes[pos + 1 .. $];
      }
      name = name.replace("+", " ");
      value = value.replace("+", " ");

      output ~= pair(decodeComponent(name), decodeComponent(value));
   }
   return output;
}

/// https://infra.spec.whatwg.org/#c0-control
private enum C0ControlSet = [
   '\u0000', '\u0001', '\u0002', '\u0003', '\u0004', '\u0005',
   '\u0006', '\u0007', '\u0008', '\u0009', '\u000A', '\u000B',
   '\u000C', '\u000D', '\u000E', '\u000F', '\u0010', '\u0011',
   '\u0012', '\u0013', '\u0014', '\u0015', '\u0016', '\u0017',
   '\u0018', '\u0019', '\u001A', '\u001B', '\u001C', '\u001D',
   '\u001E', '\u001F'
];
/// https://url.spec.whatwg.org/#query-percent-encode-set
/// NOTE: Manually need to check for > U+007E (~)
private enum QueryPercentEncodeSet = C0ControlSet ~ [
   '\u0020', '\u0022', '\u0023', '\u003C', '\u003E'
];

private enum PathPercentEncodeSet = QueryPercentEncodeSet ~ [
   '\u003F', '\u0060', '\u007B', '\u007D'
];
/// https://url.spec.whatwg.org/#userinfo-percent-encode-set
private enum UserInfoPercentEncodeSet = PathPercentEncodeSet ~ [
   '\u002F', '\u003A', '\u003B', '\u003D', '\u0040', '\u005B',
   '\u005C', '\u005D', '\u005E', '\u007C'
];
/// https://url.spec.whatwg.org/#component-percent-encode-set
private enum ComponentPercentEncodeSet = UserInfoPercentEncodeSet ~ [
   '\u0024', '\u0026', '\u0027', '\u0028', '\u0029', '\u002A', '\u002B',
   '\u002C'
];
/// https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set
private enum PercentEncodeSet = ComponentPercentEncodeSet ~ [
   '\u0021', '\u0027', '\u0028', '\u0029', '\u007E'
];

pure @safe
private string percentEncode(string input, char[] encodeSet, bool spaceAsPlus)
{
   import std.algorithm.searching : canFind;
   import std.outbuffer : OutBuffer;

   auto output = new OutBuffer();

   foreach(char c; input ) {
      if (c == ' ' && spaceAsPlus) {
         output.write('+');
         continue;
      }
      if (canFind(encodeSet, c)) {
         output.writef("%%%02X", c);
      } else if (c > '\u007E') {
         output.writef("%%%02X", c);
      } else {
         output.write(c);
      }
   }

   return output.toString();
}

///
/// The `URLSearchParams` API provides read and write methods to work
/// with the query string of a URL.
///
/// Standard: https://url.spec.whatwg.org/#urlsearchparams
///
public class URLSearchParams
{
package(mlib):

   private pair[] list;

   ///
   /// Parse the *init* string as a query string, and use it to instantiate
   /// a new `URLSearchParams` object.  A leading `?`, if present, is
   /// ignored.
   ///
   public this(string init = "") @trusted pure
   {
      if (init.length > 0) {
         list = init[0] == '?' ? parse(init[1..$]) : parse(init);
      }
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("user=abc&query=xyz");
      assert(params.get("user") == "abc");
      assert(params.toString() == "user=abc&query=xyz",
         params.toString());

      params = new URLSearchParams("?user=abc&query=xyz");
      assert(params.get("user") == "abc");
      assert(params.toString() == "user=abc&query=xyz");
   }

   ///
   /// Copy the query string from an existing `URLSearchParams`
   /// in to a new instance.
   ///
   /// Each instance maintains it's own list of paramters.
   ///
   public this(URLSearchParams other) @trusted pure
   {
      this(other.toString());
   }

   ///
   @safe pure unittest
   {
      auto params = new URLSearchParams("query[]=apple");
      assert(
         params.toString() == "query%5B%5D=apple",
         params.toString());

      auto newParams = new URLSearchParams(params);
      newParams.append("query[]", "the fruit");
      assert(
         newParams.toString() == "query%5B%5D=apple&query%5B%5D=the+fruit",
         newParams.toString());
   }

   ///
   /// The number of search parameter entries.
   ///
   public size_t length() @trusted pure const
   {
      return list.length;
   }

   public alias size = length;

   ///
   /// Append the specified key/value pair as a new search parameter.
   ///
   /// As shown in the example below, if the same key is appended multiple
   /// times it will appear in the parameter string multiple times for
   /// each value.
   ///
   /// Params:
   ///   name = The name of the parameter to append
   ///   value = The value of the parameter to append
   ///
   public void append(string name, string value) @trusted pure
   {
      this.list ~= pair(name, value);
      // NOTE: We diverge from the spec here, since we don't
      // keep track of a URL object. So no need to 'update'.
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("foo=1&bar=2");
      // Add a second foo parameter.
      params.append("foo", "4");
      assert(params.toString() == "foo=1&bar=2&foo=4");
   }

   ///
   /// Removes the specified parameters and their associated
   /// value(s) from the list of search parameters.
   ///
   /// A parameter name and optional value are used to match
   /// parameters. If only a parameter name is specified, then
   /// all search parameters that match the name are removed,
   /// along with their associated values. If both a parameter
   /// name and value are specified, then all search parameters
   /// that match both the parameter name and value are removed.
   ///
   /// NOTE: This method is called `delete()` on the specification,
   /// however, `delete` is a keyword in D.
   ///
   /// Params:
   ///    name = The name of the parameter to be removed
   ///
   public void remove(string name) @trusted pure
   {
      import std.algorithm.iteration : filter;
      import std.array : array;

      this.list = filter!(p => p.name != name)(this.list).array;
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("foo=1&bar=2&foo=3");
      assert(params.toString() == "foo=1&bar=2&foo=3", params.toString());
      params.remove("foo");
      assert(params.toString() == "bar=2", params.toString());
   }

   ///
   /// Removes the specified parameters and their associated
   /// value(s) from the list of search parameters.
   ///
   /// A parameter name and optional value are used to match
   /// parameters. If only a parameter name is specified, then
   /// all search parameters that match the name are removed,
   /// along with their associated values. If both a parameter
   /// name and value are specified, then all search parameters
   /// that match both the parameter name and value are removed.
   ///
   /// NOTE: This method is called `delete()` on the specification,
   /// however, `delete` is a keyword in D.
   ///
   /// Params:
   ///    name = The name of the parameter to be removed
   ///    value = The value that parameters must match, along with
   ///            the given name, to be removed.
   ///
   public void remove(string name, string value) @trusted pure
   {
      import std.algorithm.iteration : filter;
      import std.array : array;

      this.list = filter!(p => pair(name, value) != p)(this.list).array;
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("foo=1&bar=2&foo=3&foo=1");
      assert(params.toString() == "foo=1&bar=2&foo=3&foo=1");
      params.remove("foo", "1");
      assert(params.toString() == "bar=2&foo=3", params.toString);
   }

   ///
   /// Returns the value of the first name-value pair whose name
   /// is *name*.  If there are no such pairs, `null` is returned.
   ///
   public string get(string name) @trusted pure const
   {
      foreach(p; list) {
         if (p.name == name) {
            return p.value;
         }
      }
      return null;
   }

   ///
   unittest
   {
      import std.conv : to;

      const params = new URLSearchParams("name=JohnDoe&age=0");
      const name = params.get("name");
      const age = to!int(params.get("age"));

      assert(name == "JohnDoe");
      assert(age == 0);
      assert(params.get("address") is null);
   }

   ///
   /// Retrieve all the values associated with a given search
   /// parameter *name* as an array.
   ///
   /// Params:
   ///    name = The name of the parameter to return.
   ///
   /// Returns:
   ///    An array of strings, which may be empty if no values
   ///    for a given parameter are found.
   ///
   public string[] getAll(string name) @trusted pure const
   {
      import std.algorithm.iteration : each;

      string[] values;

      this.list.each!((p) {
         if (p.name == name) {
            values ~= p.value;
         }
      });

      return values;
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("foo=1&bar=2");
      params.append("foo", "4");
      assert(params.getAll("foo") == ["1", "4"]);
   }

   ///
   /// Indicates whether the specified *name* is in the search
   /// parameters.
   ///
   /// A parameter *name* and optional value are used to match paramters.
   /// If only a paramter name is specified, then the method will return
   /// `true` if any paramters in the query string match the name, and
   /// `false` otherwise.  If both parameter name and value are specified,
   /// then the method will return `true` if a parameter matches both the
   /// name and value.
   ///
   /// Params:
   ///    name = The name of the paramter to match.
   ///
   /// Returns:
   ///    A boolean value indicating the precense of *name*.
   ///
   public bool has(string name) @trusted pure const
   {
      import std.algorithm.searching : any;

      return this.list.any!(p => p.name == name);
   }

   ///
   @trusted pure unittest
   {
      const params = new URLSearchParams("foo=1&bar=2&foo=3");
      assert(params.has("bar"));
      assert(false == params.has("bark"));
      assert(params.has("foo"));
   }

   ///
   /// Indicates whether the specified *name* is in the search
   /// parameters.
   ///
   /// A parameter *name* and optional value are used to match paramters.
   /// If only a paramter name is specified, then the method will return
   /// `true` if any paramters in the query string match the name, and
   /// `false` otherwise.  If both parameter name and value are specified,
   /// then the method will return `true` if a parameter matches both the
   /// name and value.
   ///
   /// Params:
   ///    name = The name of the paramter to match.
   ///    value = The value of the paramter, along with the given name,
   ///            to match.
   ///
   /// Returns:
   ///    A boolean value indicating the precense of *name*.
   ///
   public bool has(string name, string value) @trusted pure const
   {
      import std.algorithm.searching : canFind;

      return this.list.canFind!(p => pair(name, value) == p);
   }

   ///
   @trusted pure unittest
   {
      const params = new URLSearchParams("foo=1&bar=2&foo=3");
      assert(false == params.has("bar", "1"));
      assert(params.has("bar", "2"));
      assert(false == params.has("foo", "4"));
   }

   ///
   /// Returns a range allowing iteration through all keys
   /// contained in this object.  The keys are all strings.
   ///
   public auto keys() @trusted pure const
   {
      import std.algorithm.iteration : map;

      return map!(x => x.name)(this.list);
   }

   ///
   @trusted pure unittest
   {
      import std.string : startsWith;
      import std.outbuffer : OutBuffer;

      const searchParams = new URLSearchParams("key=value1&key2=value2");
      auto buffer = new OutBuffer();

      foreach(key; searchParams.keys()) {
         assert(key.startsWith("key"));
         buffer.writef("%s\n", key);
      }

      assert(buffer.toString() == "key\nkey2\n");
   }

   ///
   /// Sets the value associated with a given search parameter
   /// to the given value.
   ///
   /// If there were several matching values, this method deletes
   /// the others.  If the search parameter doesn't exist, this
   /// method creates it.
   ///
   /// Params:
   ///    name = The name of the parameter to set.
   ///    value = The value of the parameter to set.
   ///
   public void set(string name, string value) @trusted pure
   {
      import std.algorithm.mutation : remove;

      bool found = false;

      for (auto i = 0; i < this.list.length;) {
         const current = list[i];
         if (current.name == name) {
            if (!found) {
               list[i].value = value;
               found = true;
               i += 1;
            } else {
               list = list.remove(i);
               // Do not increment.
               // Will keep the same index next iteration.
            }
         } else {
            i += 1;
         }
      }

      if (!found) {
         append(name, value);
      }
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("foo=1&bar=2&foo=3");
      assert(params.toString() == "foo=1&bar=2&foo=3");

      params.set("foo", "4");
      params.set("baz", "1");
      assert(params.toString() == "foo=4&bar=2&baz=1");
   }

   ///
   /// Sorts all existing name-value pairs in-place by their
   /// names.
   ///
   /// Sorting is done with a [stable sorting algorithm][1], so
   /// relative order between name-value pairs with the same name
   /// is preserved.
   ///
   /// [1]: https://dlang.org/phobos/std_algorithm_mutation.html#.SwapStrategy
   public void sort() @trusted pure
   {
      import std.algorithm.mutation : SwapStrategy;
      import std.algorithm.sorting : sort;

      this.list.sort!((p1, p2) => p1.name < p2.name, SwapStrategy.stable);
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("c=4&a=2&b=3&a=1");
      params.sort();
      // note that a=2 comes before a=1, same as input string
      assert(params.toString() == "a=2&a=1&b=3&c=4");
   }

   ///
   /// Returns a query string suitable for use in a URL.
   ///
   /// Characters are percent-encoded where necessary.
   ///
   override public string toString() const @trusted pure
   {
      import std.outbuffer : OutBuffer;

      auto output = new OutBuffer();
      bool appended = false;

      foreach(p; this.list) {
         const name = percentEncode(p.name, PercentEncodeSet, true);
         const value = percentEncode(p.value, PercentEncodeSet, true);
         if (appended) {
            output.write('&');
         }
         output.writef("%s=%s", name, value);
         appended = true;
      }

      return output.toString();
   }

   ///
   @trusted pure unittest
   {
      auto params = new URLSearchParams("query[]=abc&type=search");
      params.append("sort", "date");
      params.sort();
      assert(params.toString() == "query%5B%5D=abc&sort=date&type=search",
         params.toString());
   }


   ///
   /// Returns a range allowing iteration through all values
   /// contained in this object.  The values are all strings.
   ///
   public auto values() @trusted pure const
   {
      import std.algorithm.iteration : map;

      return this.list.map!(x => x.value);
   }

   ///
   @trusted pure unittest
   {
      import std.outbuffer : OutBuffer;
      import std.string : startsWith;

      const params = new URLSearchParams("key1=value1&key2=value2");
      auto buffer = new OutBuffer();

      foreach(value; params.values()) {
         assert(value.startsWith("value"));
         buffer.writef("%s\n", value);
      }

      assert(buffer.toString() == "value1\nvalue2\n");
   }

   public int opApply(scope int delegate(string name, string value) dg)
   {
      foreach(pair p; this.list) {
         int res = dg(p.name, p.value);
         if (res)
            return res;
      }
      return 0;
   }

   unittest
   {
      auto params = new URLSearchParams("foo=bar");
      params.append("baz", "qux");

      foreach(key, value; params) {
         assert(params.get(key) == value);
      }
   }
}

///
@safe pure unittest
{
   auto params = new URLSearchParams("abc=123");
   assert(params.get("abc") == "123");

   params.append("abc", "xyz");
   assert(params.toString() == "abc=123&abc=xyz");

   params.remove("abc");
   params.set("a", "b");
   assert(params.toString() == "a=b");

   auto newParams = new URLSearchParams(params);
   newParams.append("a", "c");
   assert(params.toString() == "a=b");
   assert(newParams.toString() == "a=b&a=c");
}
